This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / routes / [handle] / +page.ts
11 kB 343 lines
1import type { Did } from "@atcute/lexicons/syntax"; 2import { createBobbinClient } from "$lib/api/client"; 3import { fetchPage, items } from "$lib/api/pagination"; 4import { count } from "$lib/api/count"; 5import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records"; 6import { IdentityCache } from "$lib/api/identity"; 7import { didFromUri, rkeyFromUri } from "$lib/api/uri"; 8import { toHttpError, parallel } from "$lib/api/load"; 9import { search } from "$lib/api/search"; 10import type { BobbinContext } from "$lib/api/client"; 11import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph"; 12import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; 13import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; 14import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; 15import type { 16 RepoCardData, 17 StringCardData, 18 PersonData, 19 VouchData, 20 StarData 21} from "$lib/components/profile/types"; 22import type { PageLoad } from "./$types"; 23 24const PAGE_LIMIT = 50; 25 26const TABS = [ 27 "overview", 28 "repos", 29 "starred", 30 "strings", 31 "followers", 32 "following", 33 "vouches" 34] as const; 35type Tab = (typeof TABS)[number]; 36 37const normalizeTab = (raw: string | null): Tab => 38 TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; 39 40interface ListItem { 41 uri: string; 42 value: unknown; 43} 44 45const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 46 const value = item.value as RepoRecord; 47 return { 48 rkey: rkeyFromUri(item.uri), 49 name: value.name ?? rkeyFromUri(item.uri), 50 repoDid: value.repoDid ?? "", 51 ownerHandle, 52 description: value.description, 53 knot: value.knot, 54 createdAt: value.createdAt 55 }; 56}; 57 58interface ResolveRepoCardOptions { 59 viewerStarRkeys?: ReadonlyMap<string, string>; 60} 61 62const resolveRepoCard = async ( 63 ctx: BobbinContext, 64 item: ListItem, 65 ownerHandle: string, 66 options: ResolveRepoCardOptions = {} 67): Promise<RepoCardData> => { 68 const repo = toRepoCard(item, ownerHandle); 69 if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 70 // TODO(bobbin): instead of doing this, listing repos should return star counts 71 // and most likely other stats as well. 72 const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid); 73 return { 74 ...repo, 75 stars: stars.count, 76 viewerStarRkey: options.viewerStarRkeys 77 ? (options.viewerStarRkeys.get(repo.repoDid) ?? null) 78 : undefined 79 }; 80}; 81 82const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 83 const value = item.value as ShTangledString.Main; 84 return { 85 rkey: rkeyFromUri(item.uri), 86 ownerHandle, 87 filename: value.filename, 88 description: value.description, 89 createdAt: value.createdAt, 90 lines: value.contents?.split("\n").length ?? 1 91 }; 92}; 93 94// resolve dids -> handle/avatar, deduped, preserving input order. 95const resolvePeople = async ( 96 ctx: BobbinContext, 97 dids: string[], 98 viewerDid?: string 99): Promise<PersonData[]> => { 100 const cache = new IdentityCache(ctx); 101 const unique = [...new Set(dids)]; 102 103 const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 104 // TODO(bobbin): need bobbin to return follower / following stats when listing follows.. 105 const counts = await parallel( 106 unique.reduce( 107 (acc, did) => { 108 acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did) 109 .then((result) => result.count) 110 .catch(() => 0); 111 acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did) 112 .then((result) => result.count) 113 .catch(() => 0); 114 return acc; 115 }, 116 {} as Record<string, Promise<number>> 117 ) 118 ); 119 120 const viewerFollowRkeys = new Map<string, string>(); 121 if (viewerDid) { 122 for await (const item of items( 123 ctx, 124 "sh.tangled.graph.listFollowsBy", 125 { subject: viewerDid as Did }, 126 { maxPages: 10 } 127 )) { 128 const value = item.value as FollowRecord; 129 viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri)); 130 } 131 } 132 133 const byDid = new Map<string, PersonData>(); 134 unique.forEach((did, index) => { 135 const doc = docs[index]; 136 const followers = counts[`${did}-followers`]; 137 const following = counts[`${did}-following`]; 138 const isSelf = viewerDid === did; 139 const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined; 140 byDid.set( 141 did, 142 doc 143 ? { 144 did: doc.did, 145 handle: doc.handle, 146 avatar: doc.avatar, 147 followers, 148 following, 149 isSelf, 150 viewerFollowRkey 151 } 152 : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 153 ); 154 }); 155 return unique.map((did) => byDid.get(did) as PersonData); 156}; 157 158const resolveVouches = async ( 159 ctx: BobbinContext, 160 items: ListItem[], 161 direction: "incoming" | "outgoing" 162): Promise<VouchData[]> => { 163 const cache = new IdentityCache(ctx); 164 return Promise.all( 165 items.map(async (item): Promise<VouchData> => { 166 const value = item.value as VouchRecord; 167 const otherDid = 168 direction === "incoming" ? didFromUri(item.uri) : rkeyFromUri(item.uri); 169 const doc = await cache.resolve(otherDid).catch(() => null); 170 return { 171 uri: item.uri, 172 did: otherDid, 173 handle: doc?.handle ?? otherDid, 174 avatar: doc?.avatar, 175 kind: value.kind === "denounce" ? "denounce" : "vouch", 176 direction, 177 reason: value.reason, 178 createdAt: value.createdAt 179 }; 180 }) 181 ); 182}; 183 184const resolveStars = async ( 185 ctx: BobbinContext, 186 items: ListItem[], 187 options: ResolveRepoCardOptions 188): Promise<StarData[]> => { 189 const cache = new IdentityCache(ctx); 190 const resolved = await Promise.all( 191 items.map(async (item): Promise<StarData | null> => { 192 const value = item.value as ShTangledFeedStar.Main; 193 const subject = value.subject; 194 if (subject && "did" in subject && subject.did) { 195 try { 196 const repo = await getRepoByRepoDid(ctx, subject.did); 197 const ownerDid = didFromUri(repo.uri); 198 const owner = await cache.resolve(ownerDid).catch(() => null); 199 return { 200 kind: "repo", 201 uri: item.uri, 202 createdAt: value.createdAt, 203 repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) 204 }; 205 } catch { 206 return null; 207 } 208 } 209 if (subject && "uri" in subject && subject.uri) { 210 const ownerDid = didFromUri(subject.uri); 211 const owner = await cache.resolve(ownerDid).catch(() => null); 212 return { 213 kind: "string", 214 uri: item.uri, 215 createdAt: value.createdAt, 216 ownerHandle: owner?.handle ?? ownerDid, 217 rkey: rkeyFromUri(subject.uri) 218 }; 219 } 220 return null; 221 }) 222 ); 223 return resolved.filter((star): star is StarData => star !== null); 224}; 225 226export const load: PageLoad = async (event) => { 227 const parent = await event.parent(); 228 const tab = normalizeTab(event.url.searchParams.get("tab")); 229 230 if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; 231 232 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 233 const did = parent.identity.did as Did; 234 const handle = parent.identity.handle; 235 236 try { 237 switch (tab) { 238 case "repos": { 239 const q = event.url.searchParams.get("q")?.trim(); 240 const [found, viewerStarRkeys] = await Promise.all([ 241 q 242 ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then( 243 (page) => page.hits 244 ) 245 : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then( 246 (page) => page.items 247 ), 248 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 249 ]); 250 return { 251 tab, 252 repos: await Promise.all( 253 found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 254 ) 255 }; 256 } 257 case "strings": { 258 const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { 259 subject: did, 260 limit: PAGE_LIMIT 261 }); 262 return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 263 } 264 case "followers": { 265 const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", { 266 subject: did, 267 limit: PAGE_LIMIT 268 }); 269 const dids = page.items.map((item) => didFromUri(item.uri)); 270 return { 271 tab, 272 people: await resolvePeople(ctx, dids, parent.auth?.did) 273 }; 274 } 275 case "following": { 276 const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", { 277 subject: did, 278 limit: PAGE_LIMIT 279 }); 280 const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 281 return { 282 tab, 283 people: await resolvePeople(ctx, dids, parent.auth?.did) 284 }; 285 } 286 case "vouches": { 287 const [incomingPage, outgoingPage] = await Promise.all([ 288 fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }), 289 fetchPage(ctx, "sh.tangled.graph.listVouchesBy", { subject: did, limit: PAGE_LIMIT }) 290 ]); 291 const [incoming, outgoing] = await Promise.all([ 292 resolveVouches(ctx, incomingPage.items, "incoming"), 293 resolveVouches(ctx, outgoingPage.items, "outgoing") 294 ]); 295 const vouches = [...incoming, ...outgoing].sort( 296 (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() 297 ); 298 return { 299 tab, 300 vouches, 301 isSelf: parent.auth?.did === did, 302 profileHandle: handle 303 }; 304 } 305 case "starred": { 306 const [page, viewerStarRkeys] = await Promise.all([ 307 fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }), 308 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 309 ]); 310 return { 311 tab, 312 stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) 313 }; 314 } 315 case "overview": 316 default: { 317 const [page, viewerStarRkeys] = await Promise.all([ 318 fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }), 319 parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 320 ]); 321 322 const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 323 const byKey = new Map<string, ListItem>(); 324 for (const item of page.items) { 325 const value = item.value as RepoRecord; 326 if (value.repoDid) byKey.set(value.repoDid, item); 327 byKey.set(item.uri, item); 328 } 329 const pinnedItems = pinnedKeys 330 .map((key) => byKey.get(key)) 331 .filter((item): item is ListItem => item !== undefined); 332 const pinned = await Promise.all( 333 pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 334 ); 335 336 return { tab: "overview" as const, overview: { pinned } }; 337 } 338 } 339 } catch (cause) { 340 console.error("Page load error:", cause); 341 toHttpError(cause, "Could not load profile data"); 342 } 343};